Day 6 結尾承認的限制今天處理:API 沒有記憶,conversation_id 收了三天還是保留欄位。先把一個幻覺拆掉——LLM API 本身沒有「對話」這個概念,每次呼叫都是獨立的推論;所謂記憶,是有人在每一輪把歷史重新組好、塞回 input。今天要回答的問題只有一個:那個「有人」該是誰。讀完你會知道 Responses API 給的三條路各自藏著什麼約束、為什麼在我們這種標準 HTTP、跨 request 的架構下 Day 5 就已經把這題答完了,以及接手歷史之後最反直覺的一課:你要重放的不是「訊息」,是 items。
Responses API 管理多輪對話有三種方式(查核 2026-07,conversation state 指南):
| 方式 | 狀態放哪 | 一句話 |
|---|---|---|
| 自己帶歷史 | 你的系統 | 每輪把完整重放上下文(provider items)塞進 input |
previous_response_id |
Azure 伺服器端 | 只送新訊息,帶上一輪的 response id 接續 |
| Conversations API | Azure 伺服器端 | 建一個持久的 conversation 物件,往裡面丟訊息 |
第一條路最直接:狀態在你手上,儲存、截斷、刪除都是你的事。後兩條路把這些工作外包給平台,代價則藏在保留政策、可觀測性與可用範圍裡。
previous_response_id 的使用體驗很漂亮:每輪只送新訊息,伺服器端把之前的上下文重組進模型輸入。但它有一個結構性前提:上一輪的 response 必須存在伺服器端,否則沒東西可接。
這不是文件角落的小字,是可以實測的行為。對 Day 4 建的 japaneast 資源,先用 store=false 拿到一個 response id,下一輪引用它(實測 2026-07,v1 GA API):
{
"error": {
"message": "Previous response with id 'resp_0434...' not found.",
"type": "invalid_request_error",
"param": "previous_response_id",
"code": "previous_response_not_found"
}
}
400,previous_response_not_found。官方 migration 指南的串接範例也都同時設 store: true。在跨 request 的 HTTP 架構下,伺服器端接續與「資料不落地」互斥,這是定義問題,不是版本問題。
範圍要說精確:這條互斥有一個 transport 例外。Responses 的 WebSocket mode 可以在 store=false 下用 connection-local 的記憶體快取接續同一條連線內的上一筆 response。
但斷線重連或 cache 失效就沒有任何持久化的後盾,一樣回 previous_response_not_found(查核 2026-07,WebSocket mode 文件)。
我們的 FastAPI 是標準 HTTP、每一輪是獨立的 request,靠不了單一連線的快取;本篇的結論都以這個架構為前提。
再來是錢。直覺會說「伺服器都幫我記了,我不用重送歷史,token 費應該省下來了」。官方文件的原話是反的:使用 previous_response_id 時,鏈上所有先前輪次的 input token,仍會計入後續呼叫的 input token 用量(查核 2026-07,conversation state 指南)。
想通並不難:模型每輪本來就要重讀整段上下文,誰組的 input 都一樣要計量(實際金額還會受 cached input 費率影響,但「歷史不免費」這一點兩種方式都逃不掉)。它省掉的是你帶歷史的頻寬與組裝程式碼,不是歷史 token 的計量。
忍喵:「『伺服器幫你記』不等於『歷史免費』——鏈上的舊 token 每輪照樣進 input 計量。省下的是程式碼,不是錢。」
還有一個代價藏在控制面:歷史由伺服器端重組,你的 request 裡只有新訊息。這一輪會送多大、要不要先截斷,事前你無從介入,只能事後從 usage.input_tokens 倒推總量,而且看不到 item 層級的組成。Day 9 要做 token budget 防線時,「事前控制」正是重點,這個洞會很痛。
第三條路更省事:conversation 物件有自己的持久 id,跨裝置、跨 session 都能接續。
但在 Azure 上,它目前處於一種微妙的狀態:官方 v1 REST 參考已經文件化 Conversations endpoints,而我在 japaneast 資源上實測 POST /openai/v1/conversations 得到 404(實測 2026-07)。
這是文件與 runtime 的落差:文件存在不代表 rollout 到了你的資源,單一資源的 404 也不代表整個平台都沒有;它是「該資源、該時點」的觀察,隨時可能改變。對選型的實際意義是:你不能把它當成今天可依賴的選項,得自己對自己的資源驗證。
就算在你的資源上通了,選它之前還有一個治理問題要先答:在 OpenAI 端的現行行為裡,stored responses 預設保留 30 天,而官方文件明確說 conversation 物件與其中的 items 不受這個 30 天保留期限制(查核 2026-07,conversation state 指南)。
該文件也沒有列出另一個自動到期期限,但「不受這個 30 天 TTL、文件沒列別的期限」到此為止,它不排除其他生命週期政策(帳號層級、服務端、條款層面)的存在;Azure 端的保留與自動到期 contract 目前也還沒有文件化。
Day 5 說過 store 的預設值是一個沒通知你的資料治理決策;Conversations API 把同一件事放大:保留多久、誰負責刪,文件沒替你答,選它之前得自己定義刪除政策、並確認產品適用的條款。
把兩節的結論疊起來,範圍限定在本系列的架構(標準 HTTP、每輪獨立 request、不能依賴單一連線的快取):previous_response_id 需要 stored responses,我們 Day 5 為了狀態主權設了 store=False;Conversations API 今天無法依賴,就算通了也得先回答更重的保留政策。三選一收斂成單選——自己帶歷史。
我想強調的是順序:不是「我們想自管歷史,所以設了 store=False」,而是「治理決策先定了,架構選項自然塌縮」。這個順序才是本篇的重點:conversation state 放哪,是治理決策的下游。反過來走的團隊會在上線前的資料保護審查時,才發現自己被預設值代簽了一份保留政策。
接手狀態主權,代價是一張清單:存哪裡、歷史長什麼樣、什麼進歷史、多長算太長、誰能看。今天處理前四項,最後一項是 Day 19–22 的安全篇。
架構上只加一層:ConversationStore Protocol,跟 Day 4 的 ChatService 同一個手法。介面自己定,實作可替換,組合點只有一處。
class ConversationStore(Protocol):
async def get(self, conversation_id: str) -> Conversation | None: ...
async def append(
self,
conversation_id: str,
turns: Sequence[Message],
replay_items: Sequence[ReplayItem],
expected_revision: int,
) -> None: ...
兩個方法就夠:讀歷史、追加一輪。append 的合約有兩條:all-or-nothing(會失敗的步驟全部先做完才動資料,半筆寫入是壞掉的 store,不是比較小的 commit);conditional(呼叫端要交出讀歷史時拿到的 revision,版本不對就整筆拒絕)。replay_items 是什麼、為什麼跟 turns 分開,下一節講。
今天的實作是 in-memory dict,限制寫在臉上:process 重啟就失憶、多 replica 彼此看不見。它存在的意義是把介面立起來:哪天換 Azure Cosmos DB 或 Azure Database for PostgreSQL,改的是 build_conversation_store 一個函式,handlers 一行不動。
忍喵:「第一天就上 Cosmos 的人,通常還答不出『對話要保留多久、誰能刪』。先用一個會失憶的 dict 把介面逼出來,答案想清楚了再花錢。」
不過「介面先行」不是「把難題留給未來」。有兩個問題現在就得定下來,否則 persistent 實作一來就會踩爆:
儲存失敗算誰的? LLM 推論成功、落庫失敗:這時上游已經計費,但 turn 沒有 commit。它必須走同一套錯誤合約:回應送出前是 500 storage_error envelope;200 之後(streaming 中)是 SSE error 終端事件。client 重試會重新推論、重新計費。這是「先推論後落庫」的誠實代價,不加 idempotency 機制就無法消除。
同一段對話的並發呢? 使用者連點兩下、開兩個分頁、手機重送:兩個 request 同時帶同一個 conversation_id 進來,各自讀到同一份舊快照,最後落出一段誰都沒看過的因果錯亂歷史。
防線有兩層:process 內,「讀歷史 → 推論 → commit」是每段對話一個 critical section(per-conversation lock 序列化);介面上,append 的 expected_revision 就是條件寫入的 token。多 replica 之下 lock 救不了你,但 persistent adapter 拿同一個介面就能用資料庫的條件寫入(version/ETag)原生擋掉 stale writer。
這是接手狀態主權後最容易做錯的一步,我第一版就做錯了:把歷史存成「user 說了什麼、assistant 回了什麼」的訊息列表,下一輪重組成 [{"role": ..., "content": ...}] 送回去。看起來天經地義,但 gpt-5-mini 是 reasoning 模型,它每一輪的輸出不只有你看得見的文字,還有 reasoning items。store=False 之下這些 items 不存在伺服器端的任何地方。
你只重放文字,等於每一輪都悄悄丟掉上一輪的推理上下文:功能不會壞,品質默默變差,只盯著回覆文字的測試完全抓不到(所以 repo 後來補了 replay round-trip 測試,直接釘「items 有沒有原樣送達下一輪」)。
官方的無狀態多輪做法分兩步(查核 2026-07,Azure v1 REST 參考、reasoning 指南)。
第一步:request 帶 include=["reasoning.encrypted_content"],reasoning items 會以加密形式回到 output。
第二步:下一輪把整組 output items 原樣塞回 input。加密內容只有服務端解得開,你的系統只負責原封搬運:狀態主權在你手上,推理內容不因此暴露。
所以 store 才會有兩份表示:turns 是給人看的 transcript(顯示、稽核、fake 的斷言目標);replay_items 是給模型的重放上下文(user input item + 上一輪完整 output items,含加密 reasoning)。transcript 是投影,replay items 才是模型眼中的歷史,這正是「自己帶歷史」的完整含義:帶的不是你以為的對話,是 provider 定義的 items。
對 Day 4 建的資源實測:turn 1 的 output items 是 ['reasoning', 'message'],encrypted content 非空,第二輪原樣重放後模型正確接續(實測 2026-07)。
自管歷史最容易被跳過的問題:失敗的那一輪,要不要進歷史? 我們的規則叫 turn-commit:user 輸入與這一輪的回覆上下文是一個原子單位,成功之後一起落庫;任何失敗都不留痕跡。
async def complete(self, message: str, conversation_id: str | None) -> tuple[str, ChatResult]:
resolved_id = conversation_id or str(uuid4())
await self._acquire(resolved_id)
try:
conversation = await self._load(conversation_id, resolved_id)
user_item = _user_item(message)
result = await self._chat_service.complete([*conversation.replay_items, user_item])
if not result.message:
# A 200 with a freshly issued id that resolves to 404 next
# turn would break the contract; an empty reply is an
# upstream failure, not a turn (review r01 finding 4).
raise UpstreamServiceError("upstream returned an empty reply")
await self._commit(
resolved_id,
[
Message(role="user", content=message),
Message(role="assistant", content=result.message),
],
[user_item, *result.replay_items],
expected_revision=conversation.revision,
)
finally:
self._release(resolved_id)
return resolved_id, result
幾件事都在這段裡。_acquire/_release 是上一節說的 per-conversation critical section(lock entry 有 reference count,最後一個等待者離開就回收,拿不存在的 id 亂敲也撐不大 registry);_commit 交出讀歷史時的 revision,讓條件寫入從第一天就是介面的一部分。
再來,_commit 在 LLM 呼叫之後:如果先存 user 訊息、呼叫失敗再回頭刪,你就發明了一個需要補償邏輯的分散式問題,先呼叫後存,失敗的輪次從未存在過,client 重試也不會在歷史裡留下重複的提問。
而空回覆直接視為上游故障回 502,寧可誠實失敗,也不發一個 200 附帶一個下一輪會 404 的幽靈 id。上一篇的 429、timeout、content filter 全部適用同一條:錯誤回應照 Day 5 的映射表走,歷史保持乾淨。
streaming 的版本多一個轉折:要等終端事件才知道這一輪的結局。commit 條件直接對齊 Day 6 的合約,client 會保留的結局才落庫:
if isinstance(event, StreamDone):
keeps_text = event.status == "completed" or (
event.incomplete_reason == "max_output_tokens"
)
if keeps_text:
text = "".join(parts)
turns = [Message(role="user", content=message)]
if text:
turns.append(Message(role="assistant", content=text))
# Commit before the terminal is delivered: when the
# client sees message.done, the history it implies
# already exists.
await self._commit(
conversation_id,
turns,
[user_item, *event.replay_items],
expected_revision=expected_revision,
)
yield event
return
completed 落庫;max_output_tokens 截斷落部分文字,那是 client 實際看到的內容。但 content_filter 不落:Day 6 的合約要求 client 丟棄或遮蔽這段文字,如果伺服器端反手存一份,你保存的正是 client 被要求銷毀的內容。那不是紀錄,是隱患。error 終端一樣不落;client 在終端事件抵達前斷線,這一輪也不落。
斷線這條要說到多精確才誠實?commit 發生在 message.done 送出之前,所以保證是單向的:client 收到 done,歷史一定已存在,不會有「done 了但下一輪讀不到」的縫隙。反過來不成立:終端已消化、commit 已完成、done 卻死在半路的窗口是存在的,任何 transport 都無法證明跨斷線的投遞(Day 6 就說過同一句)。所以合約說的是「終端前斷線不落庫」,不是「斷線一律不落庫」。
Day 3 埋的保留欄位今天有語意了。省略 conversation_id 就開新對話,response 把 id 還給你;帶著它來,就接續。而未知的 id(打錯字、伺服器重啟弄丟、未來過期)回 404 conversation_not_found,走同一個 error envelope。
這裡有個可以偷學的對照:上游對同類錯誤選了 400(previous_response_not_found,參數層級的錯);我們選 404,因為在我們的 API 裡 conversation 是資源,「找不到資源」的語意工具 HTTP 本來就有。兩種都對,重點是選過,而且 404 的訊息直接告訴 client 怎麼辦:重開一段對話就好,三種「未知」的處置都一樣,client 不需要分辨。
streaming endpoint 的 id 走 X-Conversation-Id response header 而不是塞進事件裡:SSE client 在 response 抵達時就需要它(下一輪要用),不該逼 client 從事件流裡撈合約資料。這也是 Day 6「詞彙表刻意定得小」的延續:事件只承載訊息內容與結局,索引資料放 header。
但 header 出門的時候,這一輪的結局還沒揭曉,所以首輪 streaming 的 id 是暫定的(provisional):只有等到 client 會保留的終端(message.done 的 completed 或 max_output_tokens),這個 id 才真的存在;error、content_filter/other、或中途斷線之後拿它續聊,會得到 404。這條寫進了 OpenAPI 的 header description:id 的有效性跟著 turn-commit 走,不跟著 header 走。
對話會長大,context window 不會。gpt-5-mini 的窗是 400K token:input 272K、output 128K(查核 2026-07,models 文件),聽起來很遠,但自管歷史代表「什麼時候會撞牆、撞牆時發生什麼事」現在是你的問題。
Responses API 有個 truncation 參數管這件事,允許值只有兩個:auto 與 disabled。這不是從文件抄的,是對 Azure 塞非法值逼出來的(實測 2026-07):
{
"error": {
"message": "Invalid value: 'bogus'. Supported values are: 'auto' and 'disabled'.",
"param": "truncation",
"code": "invalid_value"
}
}
預設 disabled:超窗直接 400 context_length_exceeded,正是 Day 5 映射表裡那個 400 invalid_input,我們的錯誤合約不用改一個字就把這個失敗模式接住了。
而 auto 會從對話開頭丟棄 items 硬塞進窗裡:官方 reference 的原文是「dropping items from the beginning of the conversation」(查核 2026-07,responses reference)。最舊的上下文最先消失、丟到哪裡停不由你,而且同一個參數已被標記 Deprecated。
退場方向也很明確:官方正把 context 管理往 context_management/server-side compaction 收攏,而且 compaction 明文支援 store=false(查核 2026-07,Azure Responses 文件)。Day 9 設計 token 防線時會以那條路為準,而不是押在 truncation 上。
我們選擇維持預設 disabled,本篇不實作截斷。這是有意識的延遲:訊息數不等於 token 數,像樣的截斷策略需要 token 計數,那是 Day 9 token budget 的正題。在那之前,400 是誠實的 backstop:對話撞牆時 client 得到明確的錯誤,而不是模型悄悄忘記前半段。
環境前提:day-07 tag 的程式碼、Python 3.13 + uv(macOS,2026-07 實測)。預設 fake,起服務後打兩輪:
curl -s localhost:8000/api/v1/chat -X POST \
-H 'content-type: application/json' -d '{"message": "ping"}'
{"message":"[fake-llm] ping","conversation_id":"1b37…","correlation_id":"…"}
把拿到的 id 帶進第二輪:
curl -s localhost:8000/api/v1/chat -X POST \
-H 'content-type: application/json' \
-d '{"message": "again", "conversation_id": "<上一輪的 id>"}'
{"message":"[fake-llm] again (history=2)","conversation_id":"1b37…","correlation_id":"…"}
那個 (history=2) 是 fake 刻意暴露的標記:它不會假裝自己有記憶,只證明兩則歷史訊息真的送到了模型手上。這正是合約測試要的可觀測性,BDD 的斷言就釘在這個標記上。切到真實路徑(.env 設 Day 4 的三個值),對話就真的有記憶了:第一輪告訴它一個代號,第二輪問「我的代號是什麼」,gpt-5-mini 從我們組的歷史裡答回來;streaming 端點同一個 id 也接得上,header 帶著 X-Conversation-Id。
BDD 共五條情境:首輪發 id、次輪帶歷史、未知 id 回 404 envelope、失敗的輪次不留痕跡(先成功一輪、再失敗一輪、恢復後歷史仍是 2 不是 4)、streaming 續接同一段對話。
單元測試層再把難重現的角落釘住:兩個並發 request 打同一段對話必須序列化、store 落庫失敗在兩條路上都走 storage_error(500 envelope/SSE error 終端)、replay items 原樣送達下一輪、斷線與 content_filter 不落庫。完整程式碼與測試在 day-07 tag。
一個 in-memory store 特有的坑先預告:uvicorn --workers 2 起兩個 process,每個 worker 一份 dict;同一個 conversation_id 會隨機 404,看起來像 bug,其實是「in-memory 對多 replica 不可見」這句限制的現場演出。demo 用單 worker 沒事;這個坑正是之後換 persistent store 的第一個理由。
自管歷史不是唯一正解。previous_response_id 適合這些情境:單一供應商、client 頻寬敏感(行動端不想每輪上傳整段歷史)、而且你有意識地接受 store=true 的 30 天保留。治理決策自己做過,它就是合理選項。
Conversations API 若在你的資源上驗證可用(它的可用性是「資源/時點」層級的 runtime 狀態,不是全平台開關),適合真的需要跨裝置、長生命週期對話的產品,前提是你先定義得出保留與刪除政策。文件只說它不受 30 天 TTL、沒列另一個期限,其餘的 lifecycle 得靠你的政策與條款補齊,而 Azure 端的 retention contract 目前還沒有文件化。
不適合自管的情境也有:如果你的產品根本沒有多輪需求(一問一答的工具型 API),這整篇的機制都是過度設計。conversation_id 留 null,每輪獨立,最便宜。
conversation_id 從保留欄位變成合約:省略即開新對話、未知 id 回 404、streaming 用 header 回傳(首輪暫定,跟著 turn-commit 生效)。狀態主權接手完成:ConversationStore Protocol 立了介面,歷史以 transcript+replay items 雙表示落庫(reasoning 上下文靠後者活過 store=False 的多輪)。
turn-commit 定了「什麼進歷史」:成功的輪次在 per-conversation critical section 裡原子落庫;失敗、content_filter、終端前斷線不留痕跡;落庫本身失敗則以 storage_error 走同一套錯誤合約。
限制照樣攤開:store 是會失憶的 in-memory,persistent 實作是介面的下一步(條件寫入的要求已寫進 Protocol);截斷只有 400 backstop,token 層級的防線在 Day 9;推論成功但落庫失敗的重試會重新計費,idempotency 機制還沒有;conversation 沒有綁認證,拿到 id 就能續聊,Day 19–22 處理。
還有一件事你可能已經注意到:給人看的 transcript 只有 user 和 assistant,provider replay history 裡則還有 reasoning 等 output items,但兩邊都沒有 system prompt 的位置。它不該散落在程式碼字串裡,也不該每輪重複存進歷史。prompt 的家在哪、版本怎麼管,就是明天(Day 8)的主題。
(本篇無新增雲端資源——沿用 Day 4 建立的 resource 與 deployment,純 token 計費。)
| 工程需求 | Azure / Microsoft 對應服務 | 本篇怎麼用 |
|---|---|---|
| LLM 推論 API | Azure OpenAI in Microsoft Foundry Models | 沿用 Day 4 的 chat-mini deployment,Responses API store=False,歷史由本篇的 ConversationStore 組裝 |
| 對話持久化(延伸選項) | Azure Cosmos DB / Azure Database for PostgreSQL | 本篇僅立 ConversationStore 介面與 in-memory demo;persistent 實作為後續里程碑 |
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。